翻译|开启React,Redux和Immutable之旅:测试驱动教程(part1)

翻译版本,原文请见,第一部分,第二部分

几周以前,我正在漫无目的的浏览Hacker News,读到一篇关于Redux的头条新闻,Redux的内容我是了解,但是另一个谈到的问题javascript fatigue(JavaScript 疲劳)已经困扰我了,所以我没有太关心,知道读到Redux的几个特征.

  • 强化了函数式编程,确保app行为的可预测性
  • 允许app的同构,客户端和服务端的大多数逻辑都可以共享
  • 时间旅行的debugger?有可能吗?

Redux似乎是React程序state管理的优雅方法,再者谁说的时间旅行不可能?所以我读了文档和一篇非常精彩的教程@teropa:A Comprehensive Guide to Test-First Development with Redux,React,and Immutable(这一篇也是我写作的主要灵感来源).

我喜欢Redux,代码非常优雅,debugger令人疯狂的伟大.我的意思是-看看这个todo-action

接下来的教程第一部分希望引导你理解Redux运行的原则.教程的目的仅限于(客户端,没有同构,是比较简单的app)保持教程的简明扼要.如果你想发掘的更深一点,我仅建议你阅读上面提高的那个教程.对比版的Github repo在这里,共享代码贴合教程的步骤.如果你对代码或者教程有任何问题和建议,最好能留下留言.

编辑按:文章已经更新为ES2015版的句法.

APP

为了符合教程的目的,我们将建一个经典的TodoMVC,为了记录需要,需求如下:

  • 每一个todo可以激活或者完成
  • 可以添加,编辑,删除一个todo
  • 可以根据它的status来过滤筛选todos
  • 激活的todos的数目显示在底部
  • 完成的Todo理解可以删除

Reudux和Immutable:使用函数编程去营救

回到几个月前,我正在开发一个webapp包含仪表板. 随着app的成长,我们注意到越来越多的有害的bugs,藏在代码角落里,很难发现.类似:“如果你要导航到这一页,点击按钮,然后回到主页,喝一杯咖啡,回到这一页然后点击两次,奇怪的事情发生了.”这些bug的来源要么是异步操作(side effects)或者逻辑:一个action可能在app中有意想不到的影响,这个有时候我们还发现不了.

这就是Redux之所以存在的威力:整个app的state是一个单一的数据结构,state tree.这一点意思是说:在任何时刻,展示给用户的内容仅仅是state tree结果,这就是单一来源的真相(用户界面的显示内容是由state tree来决定的).每一个action接收state,应用相应的修改(例如,添加一个todo),输出更新的state tree.更新的结果渲染展示给用户.里面没有模糊的异步操作,没有变量的引用引起的不经意的修改.这个步骤使得app有了更好的结构,分离关注点,dubugging也更好用了.

Immutable是有Facebook开发的助手函数库,提供一些工具去创建和操作immutable数据结构.尽管在Redux也不是一定要使用它,但是它通过禁止对象的修改,强化了函数式编程方法.有了immutable,当我们想更新一个对象,实际上我们修改的是一个新创建的的对象,原先的对象保持不变.

这里是“Immutable文档”里面的例子:

1
2
3
4
5
 var map1 = Immutable.Map({a:1, b:2, c:3});
var map2 = map1.set('b', 2);
assert(map1 === map2); // no change
var map3 = map1.set('b', 50);
assert(map1 !== map3); // change

我们更新了map1的一个值,map1对象保持不变,一个新的对象map3被创建了.

Immutable在store中被用来储存我们的app的state tree.很快我们会看到Immutable提供了一下操作对象的简单和有效的方法.

配置项目

声明:一些配置有@terops的教程启发.

注意事项:推荐使用Node.js>=4.0.0.你可以使用nvm(node version manager)来切换不同的node.js的版本.

这里是比较版本的提交

开始配置项目:

1
2
3
mkdir redux-todomvc
cd redux-todomvc
npm init -y

项目的目录结构如下:

1
2
3
4
5
6
7
8
├── dist
│ ├── bundle.js
│ └── index.html
├── node_modules
├── package.json
├── src
├── test
└── webpack.config.js

首先创建一个简单的HTML页面,用来运行我们的app
dist/index.html

1
2
3
4
5
6
7
8
9
10
11
 <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React TodoMVC</title>
</head>
<body>
<div id="app"></div>
<script src="bundle.js"></script>
</body>
</html>

有了这个文件,我们写一个简单的脚本文件看看包安装的情况
src/index.js

1
console.log('Hello world !');

我们将会使用[Webpack]来打包成为bundle.js文件.Webpack的特性是速度,容易配置,大部分是热更新的.代码的更新不需要重新加载,意味着app的state保持热加载.

让我们安装webpack:

npm install —save-dev webpack@1.12.14 webpack-dev-server@1.14.1

app使用ES2015的语法,带来一些优异的特性和一些语法糖.如果你想了解更多的ES2015内容,这个recap是一个不错的资源.

Babel用来把ES2015的语法改变为普通的JS语法:
npm install —save-dev babel-core@6.5.2 babel-loader@6.2.4 babel-preset-es2015@6.5.0

我们将使用JSX语法编写React组件,所以让我们安装Babel React package:
npm install —save-dev babel-preset-react@6.5.0

配置webpack输出源文件:
package.json

1
2
3
 "babel": {
"presets": ["es2015", "react"]
}

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 module.exports = {
entry: [
'./src/index.js'
],
module: {
loaders: [{
test: /\.jsx?$/,
exclude: /node_modules/,
loader: 'babel'
}]
},
resolve: {
extensions: ['', '.js', '.jsx']
},
output: {
path: __dirname + '/dist',
publicPath: '/',
filename: 'bundle.js'
},
devServer: {
contentBase: './dist'
}
};

现在添加React和React热加载组件到项目中:

1
2
npm install --save react@0.14.7 react-dom@0.14.7
npm install --save-dev react-hot-loader@1.3.0

为了让热加载能运行,webpack.config.js文件中要做一些修改.

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
 var webpack = require('webpack'); // Requiring the webpack lib

module.exports = {
entry: [
'webpack-dev-server/client?http://localhost:8080', // Setting the URL for the hot reload
'webpack/hot/only-dev-server', // Reload only the dev server
'./src/index.js'
],
module: {
loaders: [{
test: /\.jsx?$/,
exclude: /node_modules/,
loader: 'react-hot!babel' // Include the react-hot loader
}]
},
resolve: {
extensions: ['', '.js', '.jsx']
},
output: {
path: __dirname + '/dist',
publicPath: '/',
filename: 'bundle.js'
},
devServer: {
contentBase: './dist',
hot: true // Activate hot loading
},
plugins: [
new webpack.HotModuleReplacementPlugin() // Wire in the hot loading plugin
]
};

配置单元测试框架

我们将使用Mocha和Chai来进行测试工作.这两个工具广泛的被使用,他们的输出内容对于测试驱动开发非常的好.Chai-immutable是一个chai插件,用来处理immutable数据结构.

1
2
npm install --save immutable@3.7.6
npm install --save-dev mocha@2.4.5 chai@3.5.0 chai-immutable@1.5.3

在我们的例子中,我们不会依赖浏览器为基础的测试运行器例如Karma-替代方案是我们使用jsdom库,它将会使用纯javascirpt创建一个假DOM,这样做让我们的测试更加快速.

npm install —save-dev jsdom@8.0.4

我们也需要为测试写一个启动脚本,要考虑到下面的内容.

  • 模拟documentwindow对象,通常是由浏览器提供
  • 通过chia-immutable告诉chai组件我们要使用immutable数据结构

test/setup.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 import jsdom from 'jsdom';
import chai from 'chai';
import chaiImmutable from 'chai-immutable';

const doc = jsdom.jsdom('<!doctype html><html><body></body></html>');
const win = doc.defaultView;

global.document = doc;
global.window = win;

Object.keys(window).forEach((key) => {
if (!(key in global)) {
global[key] = window[key];
}
});

chai.use(chaiImmutable);

更新一下npm test脚本
package.json

1
2
3
4
 "scripts": {
"test": "mocha --compilers js:babel-core/register --require ./test/setup.js 'test/**/*.@(js|jsx)'",
"test:watch": "npm run test -- --watch --watch-extensions jsx"
},

npm run test:watch命令在windows操作系统下似乎不能工作.

现在,如果我们运行npm run test:watch,所有test目录里的.js,.jsx文件在更新自身或者源文件的时候,将会运行mocha测试.

配置完成了:我们可以在终端中运行webpack-dev-server,打开另一个终端,npm run test:watch.在浏览器中打开localhost:8080.检查hello world!是否出现在终端中.

构建state tree

之前提到过,state tree是能提供app所有信息的数据结构.这个结构需要在实际开发之前经过深思熟虑,因为它将影响一些代码的结构和交互作用.

作为示例,我们app是一个TODO list由几个条目组合而成

state tree 1
每一个条目有一个文本,为了便于操作,设一个id,此外每个item有两个状态之一:活动或者完成:最后一个条目需要一个可编辑的状态(当用户想编辑的文本的时候),
所以我们需要保持下面的数据结构:
state tree 2

也有可能基于他们的状态进行筛选,所以我们天剑filter条目来获取最终的state tree:
sate tree 3

创建UI

首先我们把app分解为下面的组件:

  • TodoHeader组件是创建新todo的输入组件
  • TodoList组件是todo的列表
  • todoItem是一个todo
  • todoInput是编辑todo的输入框
  • TodoTools是显示未完成的条目数量,过滤器和清除完成的按钮
  • footer是显示信息的,没有具体的逻辑

我们也创建TodoApp组件组织所有的其他组件.

首次运行我们的组件

提示:运行这个版本

正如我们所见的,我们将会把所有组件放到合并成一个TodoApp组件.所以让我们添加组件到index.html文件的#appDIV中:
src/index.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from 'react';
import ReactDOM from 'react-dom';
import {List, Map} from 'immutable';

import TodoApp from './components/TodoApp';

const todos = List.of(
Map({id: 1, text: 'React', status: 'active', editing: false}),
Map({id: 2, text: 'Redux', status: 'active', editing: false}),
Map({id: 3, text: 'Immutable', status: 'completed', editing: false})
);

ReactDOM.render(
<TodoApp todos={todos} />,
document.getElementById('app')
);

因为我们在index.jsx文件中使用JSX语法,需要在wabpack中扩展.jsx.修改如下:
webpack.config.js

1
2
3
4
5
 entry: [
'webpack-dev-server/client?http://localhost:8080',
'webpack/hot/only-dev-server',
'./src/index.jsx' // Change the index file extension
],

编写todo list UI

现在我们编写第一版本的TodoApp组件,用来显示todo项目列表:
src/components/TodoApp.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
 import React from 'react';

export default class TodoApp extends React.Component {
getItems() {
return this.props.todos || [];
}
render() {
return <div>
<section className="todoapp">
<section className="main">
<ul className="todo-list">
{this.getItems().map(item =>
<li className="active" key={item.get('text')}>
<div className="view">
<input type="checkbox"
className="toggle" />
<label htmlFor="todo">
{item.get('text')}
</label>
<button className="destroy"></button>
</div>
</li>
)}
</ul>
</section>
</section>
</div>
}
};

要记住两件事情:
第一个,如果你看到的结果不太好,修复它,我们将会使用todomvc-app-css包来补充一些需要的样式

1
2
npm install --save todomvc-app-css@2.0.4
npm install style-loader@0.13.0 css-loader@0.23.1 --save-dev

我们需要告诉webpack去加载css 样式文件:
webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
// ...
module: {
loaders: [{
test: /\.jsx?$/,
exclude: /node_modules/,
loader: 'react-hot!babel'
}, {
test: /\.css$/,
loader: 'style!css' // We add the css loader
}]
},
//...

然后在inde.jsx文件中添加样式:
src/index.jsx

1
2
3
4
5
6
7
 // ...
require('../node_modules/todomvc-app-css/index.css');

ReactDOM.render(
<TodoApp todos={todos} />,
document.getElementById('app')
);

第二件事是:代码似乎很复杂,这就是我们为什么要创建两个或者多个组件的原因:TodoListTodoItem将会分别关注条目列表和单个的条目.

这一部分的提交代码

src/components/TodoApp.jsx

1
2
3
4
5
6
7
8
9
10
11
12
 import React from 'react';
import TodoList from './TodoList'

export default class TodoApp extends React.Component {
render() {
return <div>
<section className="todoapp">
<TodoList todos={this.props.todos} />
</section>
</div>
}
};

TodoList组件中根据获取的props为每一个条目显示一个TodoItem组件.

src/components/TodoList.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 import React from 'react';
import TodoItem from './TodoItem';

export default class TodoList extends React.Component {
render() {
return <section className="main">
<ul className="todo-list">
{this.props.todos.map(item =>
<TodoItem key={item.get('text')}
text={item.get('text')} />
)}
</ul>
</section>
}
};

src/components/TodoItem.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 import React from 'react';

export default class TodoItem extends React.Component {
render() {
return <li className="todo">
<div className="view">
<input type="checkbox"
className="toggle" />
<label htmlFor="todo">
{this.props.text}
</label>
<button className="destroy"></button>
</div>
</li>
}
};

在我们深入用户的交互操作之前,我们先在组件TodoItem中添加一个input用于编辑
src/componensts/TodoItem.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 import React from 'react';

import TextInput from './TextInput';

export default class TodoItem extends React.Component {

render() {
return <li className="todo">
<div className="view">
<input type="checkbox"
className="toggle" />
<label htmlFor="todo">
{this.props.text}
</label>
<button className="destroy"></button>
</div>
<TextInput /> // We add the TextInput component
</li>
}
};

TextInput组件如下
src/compoents/TextInput.jsx

1
2
3
4
5
6
7
8
9
import React from 'react';

export default class TextInput extends React.Component {
render() {
return <input className="edit"
autoFocus={true}
type="text" />
}
};

”纯“组件的好处:PureRenderMixin

这部分的提交代码

除了允许函数式编程的样式,我们的UI是单纯的,可以使用PureRenderMixin来提升速度,正如React 文档:
如果你的React的组件渲染函数是”纯“(换句话就是,如果使用相同的porps和state,总是会渲染出同样的结果),你可以使用mixin在同一个案例转给你来提升性能.

正如React文档(我们也会在第二部分看到TodoApp组件有额外的角色会阻止PureRenderMixin的使用)展示的mixin也非常容易的添加到我们的子组件中:
npm install --save react-addons-pure-render-mixin@0.14.7
src/components/TodoList.jsc

1
2
3
4
5
6
7
8
9
10
11
12
13
 import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin'
import TodoItem from './TodoItem';

export default class TodoList extends React.Component {
constructor(props) {
super(props);
this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
}
render() {
// ...
}
};

src/components/TodoItem/jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin'
import TextInput from './TextInput';

export default class TodoItem extends React.Component {
constructor(props) {
super(props);
this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
}
render() {
// ...
}
};

src/components/TextInput.jsx

1
2
3
4
5
6
7
8
9
10
11
12
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin'

export default class TextInput extends React.Component {
constructor(props) {
super(props);
this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
}
render() {
// ...
}
};

在list组件中处理用户的actions

好了,现在我们配置好了list组件.然而我们没有考虑添加用户的actions和怎么添加进去组件.

props的力量

在React中,props对象是当我们实例化一个容器(container)的时候,通过设定的attributes来设定.例如,如果我们实例化一个TodoItem元素:

1
<TodoItem text={'Text of the item'} />

然后我们在TodoItem组件中获取this.props.text变量:

1
2
3
 // in TodoItem.jsx
console.log(this.props.text);
// outputs 'Text of the item'

Redux构架中强化使用props.基础的原理是state几乎都存在于他的props里面.换一种说法:对于同样一组props,两个元素的实例应该输出完全一样的结果.正如之前我们看到的,整个app的state都包含在一个state tree中:意思是说,state tree 如果通过props的方式传递到组件,将会完整和可预期的决定app的视觉输出.

TodoList组件

这一部分的代码修改

在这一部分和接下来的一部分,我们将会了解一个测试优先的方法.

为了帮助我们测试组件,React库提供了TestUtils工具插件,有一下方法:

  • renderIntoDocument,渲染组件到附加的DOM节点
  • scryRenderDOMComponentsWIthTag,使用提供的标签(例如li,input)在DOM中找到所有的组件实例.
  • scryRederDOMComponentsWithClass,同上使用的是类
  • Simulate,模拟用户的actions(例如 点击,按键,文本输入…)

TestUtils插件没有包含在react包中,所以需要单独安装
npm install --save-dev react-addons-test-utils@0.14.7

我们的第一个测试将确保Todolist组件中,如果filterprops被设置为active,将会展示所有的活动条目:
test/components/TodoList_spec.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
   import React from 'react';
import TestUtils from 'react-addons-test-utils';
import TodoList from '../../src/components/TodoList';
import {expect} from 'chai';
import {List, Map} from 'immutable';

const {renderIntoDocument,
scryRenderedDOMComponentsWithTag} = TestUtils;

describe('TodoList', () => {
it('renders a list with only the active items if the filter is active', () => {
const todos = List.of(
Map({id: 1, text: 'React', status: 'active'}),
Map({id: 2, text: 'Redux', status: 'active'}),
Map({id: 3, text: 'Immutable', status: 'completed'})
);
const filter = 'active';
const component = renderIntoDocument(
<TodoList filter={filter} todos={todos} />
);
const items = scryRenderedDOMComponentsWithTag(component, 'li');

expect(items.length).to.equal(2);
expect(items[0].textContent).to.contain('React');
expect(items[1].textContent).to.contain('Redux');
});
});

我们可以看到测试失败了,期待的是两个活动条目,但是实际上是三个.这是再正常不过的了,因为我们没有编写实际筛选的逻辑:
src/components/TodoList.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ...
export default class TodoList extends React.Component {
// Filters the items according to their status
getItems() {
if (this.props.todos) {
return this.props.todos.filter(
(item) => item.get('status') === this.props.filter
);
}
return [];
}
render() {
return <section className="main">
<ul className="todo-list">
// Only the filtered items are displayed
{this.getItems().map(item =>
<TodoItem key={item.get('text')}
text={item.get('text')} />
)}
</ul>
</section>
}
};

第一个测试通过了.别停下来,让我们添加筛选器:allcompleted:
test/components/TodoList_spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
  // ...
describe('TodoList', () => {
// ...
it('renders a list with only completed items if the filter is completed', () => {
const todos = List.of(
Map({id: 1, text: 'React', status: 'active'}),
Map({id: 2, text: 'Redux', status: 'active'}),
Map({id: 3, text: 'Immutable', status: 'completed'})
);
const filter = 'completed';
const component = renderIntoDocument(
<TodoList filter={filter} todos={todos} />
);
const items = scryRenderedDOMComponentsWithTag(component, 'li');

expect(items.length).to.equal(1);
expect(items[0].textContent).to.contain('Immutable');
});

it('renders a list with all the items', () => {
const todos = List.of(
Map({id: 1, text: 'React', status: 'active'}),
Map({id: 2, text: 'Redux', status: 'active'}),
Map({id: 3, text: 'Immutable', status: 'completed'})
);
const filter = 'all';
const component = renderIntoDocument(
<TodoList filter={filter} todos={todos} />
);
const items = scryRenderedDOMComponentsWithTag(component, 'li');

expect(items.length).to.equal(3);
expect(items[0].textContent).to.contain('React');
expect(items[1].textContent).to.contain('Redux');
expect(items[2].textContent).to.contain('Immutable');
});
});

第三个测试失败了,因为all筛选器更新组件的逻辑稍稍有点不同
src/components/TodoList.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
   // ...
export default React.Component {
// Filters the items according to their status
getItems() {
if (this.props.todos) {
return this.props.todos.filter(
(item) => this.props.filter === 'all' || item.get('status') === this.props.filter
);
}
return [];
}
// ...
});

在这一点上,我们知道显示在app中的条目都是经过filter属性过滤的.如果在浏览器中看看结果,没有显示任何条目,因为我们还没有设置:
src/index.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 // ...
const todos = List.of(
Map({id: 1, text: 'React', status: 'active', editing: false}),
Map({id: 2, text: 'Redux', status: 'active', editing: false}),
Map({id: 3, text: 'Immutable', status: 'completed', editing: false})
);

const filter = 'all';

require('../node_modules/todomvc-app-css/index.css')

ReactDOM.render(
<TodoApp todos={todos} filter = {filter}/>,
document.getElementById('app')
);

src/components/TodoApp.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// ...
export default class TodoApp extends React.Component {
render() {
return <div>
<section className="todoapp">
// We pass the filter props down to the TodoList component
<TodoList todos={this.props.todos} filter={this.props.filter}/>
</section>
</div>
}
};
```
使用在`index.jsc`文件中声明的`filter`常量过滤以后,我们的条目重新出现了.

### TodoItem component
[这部分的代码修改](https://github.com/phacks/redux-todomvc/commit/71d2835620f4ba6f3fc3665327f13ec4fba62eee)

现在,让我们关注一下`TodoItem`组件.首先,我们想确信`TodoItem`组件真正渲染一个条目.我们也想测试一下还没有测试的特性,就是当一个条目完成的时候,他的文本中间有一条线
`test/components/TodoItem_spec.js`
```js
import React from 'react';
import TestUtils from 'react-addons-test-utils';
import TodoItem from '../../src/components/TodoItem';
import {expect} from 'chai';

const {renderIntoDocument,
scryRenderedDOMComponentsWithTag} = TestUtils;

describe('TodoItem', () => {
it('renders an item', () => {
const text = 'React';
const component = renderIntoDocument(
<TodoItem text={text} />
);
const todo = scryRenderedDOMComponentsWithTag(component, 'li');

expect(todo.length).to.equal(1);
expect(todo[0].textContent).to.contain('React');
});

it('strikes through the item if it is completed', () => {
const text = 'React';
const component = renderIntoDocument(
<TodoItem text={text} isCompleted={true}/>
);
const todo = scryRenderedDOMComponentsWithTag(component, 'li');

expect(todo[0].classList.contains('completed')).to.equal(true);
});
});

为了使第二个测试通过,如果条目的状态是complete我们使用了类complete,它将会通过props传递向下传递.我们将会使用classnames包来操作我们的DOM类.
npm install —save classnames

src/components/TodoItem.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
   import React from 'react';
// We need to import the classNames object
import classNames from 'classnames';

import TextInput from './TextInput';

export default class TodoItem extends React.Component {
render() {
var itemClass = classNames({
'todo': true,
'completed': this.props.isCompleted
});
return <li className={itemClass}>
// ...
</li>
}
};

一个item在编辑的时候外观应该看起来不一样,由isEditingprops来包裹.
test/components/TodoItem_spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
   // ...
describe('TodoItem', () => {
//...

it('should look different when editing', () => {
const text = 'React';
const component = renderIntoDocument(
<TodoItem text={text} isEditing={true}/>
);
const todo = scryRenderedDOMComponentsWithTag(component, 'li');

expect(todo[0].classList.contains('editing')).to.equal(true);
});
});

为了使测试通过,我们仅仅需要更新itemClass对象:
src/components/TodoItem.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
  // ...
export default class TodoItem extends React.Component {
render() {
var itemClass = classNames({
'todo': true,
'completed': this.props.isCompleted,
'editing': this.props.isEditing
});
return <li className={itemClass}>
// ...
</li>
}
};

条目左侧的checkbox如果条目完成,应该标记位checked:
test/components/TodoItem_spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  // ...
describe('TodoItem', () => {
//...

it('should be checked if the item is completed', () => {
const text = 'React';
const text2 = 'Redux';
const component = renderIntoDocument(
<TodoItem text={text} isCompleted={true}/>,
<TodoItem text={text2} isCompleted={false}/>
);
const input = scryRenderedDOMComponentsWithTag(component, 'input');
expect(input[0].checked).to.equal(true);
expect(input[1].checked).to.equal(false);
});
});

React有个设定checkbox输入state的方法:defaultChecked.
src/components/TodoItem.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
   // ...
export default class TodoItem extends React.Component {
render() {
// ...
return <li className={itemClass}>
<div className="view">
<input type="checkbox"
className="toggle"
defaultChecked={this.props.isCompleted}/>
// ...
</div>
</li>
}
};

我们也从TodoList组件向下传递isCompletedisEditingprops.
src/components/TodoList.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
   // ...
export default class TodoList extends React.Component {
// ...
// This function checks whether an item is completed
isCompleted(item) {
return item.get('status') === 'completed';
}
render() {
return <section className="main">
<ul className="todo-list">
{this.getItems().map(item =>
<TodoItem key={item.get('text')}
text={item.get('text')}
// We pass down the info on completion and editing
isCompleted={this.isCompleted(item)}
isEditing={item.get('editing')} />
)}
</ul>
</section>
}
};

截止目前,我们已经能够在组件中反映出state:例如,完成的条目将会被划线.然而一个webapp将会处理诸如点击按钮的操作.在Redux的模式中,这个操作也通过porps来执行,稍稍特殊的是通过在props中传递回调函数来完成.通过这种方式,我们再次把UI和App的逻辑处理分离开:组件根本不需要知道按钮点击的操作具体是什么,仅仅是点击触发了一些事情.

为了描述这个原理,我们将会测试如果用户点击了delete按钮(红色X),delteItem函数将会被调用.

这部分的代码修改

test/components/TodoItem_spec.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  / ...
// The Simulate helper allows us to simulate a user clicking
const {renderIntoDocument,
scryRenderedDOMComponentsWithTag,
Simulate} = TestUtils;

describe('TodoItem', () => {
// ...
it('invokes callback when the delete button is clicked', () => {
const text = 'React';
var deleted = false;
// We define a mock deleteItem function
const deleteItem = () => deleted = true;
const component = renderIntoDocument(
<TodoItem text={text} deleteItem={deleteItem}/>
);
const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
Simulate.click(buttons[0]);

// We verify that the deleteItem function has been called
expect(deleted).to.equal(true);
});
});

为了是这个测试通过,我们必须在delete按钮声明一个onClick句柄,他将会调用经过props传递的deleteItem函数.

src/components/TodoItem.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  // ...
export default class TodoItem extends React.Component {
render() {
// ...
return <li className={itemClass}>
<div className="view">
// ...
// The onClick handler will call the deleteItem function given in the props
<button className="destroy"
onClick={() => this.props.deleteItem(this.props.id)}></button>
</div>
<TextInput />
</li>
}
};

重要的一点:实际删除的逻辑还没有实施,这个将是Redux的主要作用.
在同一个model,我们可以测试和实施下面的特性:

  • 点击checkbox将会调用toggleComplete函数
  • 双击条目标签,将会调用editItem函数

test/components/TodoItem_spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  // ...
describe('TodoItem', () => {
// ...
it('invokes callback when checkbox is clicked', () => {
const text = 'React';
var isChecked = false;
const toggleComplete = () => isChecked = true;
const component = renderIntoDocument(
<TodoItem text={text} toggleComplete={toggleComplete}/>
);
const checkboxes = scryRenderedDOMComponentsWithTag(component, 'input');
Simulate.click(checkboxes[0]);

expect(isChecked).to.equal(true);
});

it('calls a callback when text is double clicked', () => {
var text = 'React';
const editItem = () => text = 'Redux';
const component = renderIntoDocument(
<TodoItem text={text} editItem={editItem}/>
);
const label = component.refs.text
Simulate.doubleClick(label);

expect(text).to.equal('Redux');
});
});

src/compoents/TodoItem.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  // ...
render() {
// ...
return <li className={itemClass}>
<div className="view">
// We add an onClick handler on the checkbox
<input type="checkbox"
className="toggle"
defaultChecked={this.props.isCompleted}
onClick={() => this.props.toggleComplete(this.props.id)}/>
// We add a ref attribute to the label to facilitate the testing
// The onDoubleClick handler is unsurprisingly called on double clicks
<label htmlFor="todo"
ref="text"
onDoubleClick={() => this.props.editItem(this.props.id)}>
{this.props.text}
</label>
<button className="destroy"
onClick={() => this.props.deleteItem(this.props.id)}></button>
</div>
<TextInput />
</li>

我们也从TodoList组件借助props向下传递editItem,deleteItemtoggleComplete函数.
src/components/TodoList.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  // ...
export default class TodoList extends React.Component {
// ...
render() {
return <section className="main">
<ul className="todo-list">
{this.getItems().map(item =>
<TodoItem key={item.get('text')}
text={item.get('text')}
isCompleted={this.isCompleted(item)}
isEditing={item.get('editing')}
// We pass down the callback functions
toggleComplete={this.props.toggleComplete}
deleteItem={this.props.deleteItem}
editItem={this.props.editItem}/>
)}
</ul>
</section>
}
};

配置其他组件

现在,你可能对流程有些熟悉了.为了让本文不要太长,我邀请你看看组件的代码,
TextInput(相关提交),TodoHeader(相关提交),TodotoolsFooter(相关提交)组件.如果你有任何问题,请留下评论,或着在repo的issue中留下评论.

你可能主要到一些函数,例如editItem,toggleComplete诸如此类的,还没有被定义.这些内容将会在教程的下一部分作为Redux actions的组成来定义,所以如果遇到错误,不要担心.

包装起来

在这篇文章中,我已经演示了我的第一个React,Redux和Immutable webapp.我们的UI是模块化的.完全通过测试,准备和实际的app逻辑联系起来.怎么来连接?这些傻瓜组件什么都不知道,怎么让我们可以写出时间旅行的app?

教程的第二部分在这里.